# LobeChat Feature Development Complete Guide

This document aims to guide developers on how to develop a complete feature in LobeChat.

We will use [RFC 021 - Custom Assistant Opening Guidance](https://github.com/lobehub/lobe-chat/discussions/891) as an example to illustrate the complete implementation process.

## 1. Update Schema

lobe-chat uses a postgres database, with the browser-side local database using [pglite](https://pglite.dev/) (wasm version of postgres). The project also uses [drizzle](https://orm.drizzle.team/) ORM to operate the database.

Compared to the old solution where the browser side used indexDB, having both the browser side and server side use postgres has the advantage that the model layer code can be completely reused.

All schemas are uniformly placed in `src/database/schemas`. We need to adjust the `agents` table to add two fields corresponding to the configuration items:

```diff
// src/database/schemas/agent.ts
export const agents = pgTable(
  'agents',
  {
    id: text('id')
      .primaryKey()
      .$defaultFn(() => idGenerator('agents'))
      .notNull(),
    avatar: text('avatar'),
    backgroundColor: text('background_color'),
    plugins: jsonb('plugins').$type<string[]>().default([]),
    // ...
    tts: jsonb('tts').$type<LobeAgentTTSConfig>(),

+   openingMessage: text('opening_message'),
+   openingQuestions: text('opening_questions').array().default([]),

    ...timestamps,
  },
  (t) => ({
    // ...
    // !: update index here
  }),
);

```

Note that sometimes we may also need to update the index, but for this feature, we don't have any related performance bottleneck issues, so we don't need to update the index.

After adjusting the schema, we need to run `npm run db:generate` to use drizzle-kit's built-in database migration capability to generate the corresponding SQL code for migrating to the latest schema. After execution, four files will be generated:

- src/database/migrations/meta/\_journal.json: Saves information about each migration
- src/database/migrations/0021\_add\_agent\_opening\_settings.sql: SQL commands for this migration
- src/database/client/migrations.json: SQL commands for this migration used by pglite
- src/database/migrations/meta/0021\_snapshot.json: The current latest complete database snapshot

Note that the migration SQL filename generated by the script by default is not semantically clear like `0021_add_agent_opening_settings.sql`. You need to manually rename it and update `src/database/migrations/meta/_journal.json`.

Previously, client-side storage using indexDB made database migration relatively complicated. Now with pglite on the local side, database migration is a simple command, very quick and easy. You can also check if there's any room for optimization in the generated migration SQL and make manual adjustments.

## 2. Update Data Model

Data models used in our project are defined in `src/types`. We don't directly use the types exported from the drizzle schema, such as `export type NewAgent = typeof agents.$inferInsert;`, but instead define corresponding data models based on frontend requirements and data types of the corresponding fields in the db schema definition.

Data model definitions are placed in the `src/types` folder. Update the `LobeAgentConfig` type in `src/types/agent/index.ts`:

```diff
export interface LobeAgentConfig {
  // ...
  chatConfig: LobeAgentChatConfig;
  /**
   * The language model used by the agent
   * @default gpt-4o-mini
   */
  model: string;

+  /**
+   * Opening message
+   */
+  openingMessage?: string;
+  /**
+   * Opening questions
+   */
+  openingQuestions?: string[];

  /**
   * Language model parameters
   */
  params: LLMParams;
  /**
   * Enabled plugins
   */
  plugins?: string[];

  /**
   * Model provider
   */
  provider?: string;

  /**
   * System role
   */
  systemRole: string;

  /**
   * Text-to-speech service
   */
  tts: LobeAgentTTSConfig;
}
```

## 3. Service Implementation / Model Implementation

- The `model` layer encapsulates reusable operations on the DB
- The `service` layer implements application business logic

Both have corresponding top-level folders in the `src` directory.

We need to check if we need to update their implementation. Agent configuration in the frontend is abstracted as session configuration. In `src/services/session/server.ts` we can see a service function `updateSessionConfig`:

```typescript
export class ServerService implements ISessionService {
  // ...
  updateSessionConfig: ISessionService['updateSessionConfig'] = (id, config, signal) => {
    return lambdaClient.session.updateSessionConfig.mutate({ id, value: config }, { signal });
  };
}
```

Jumping to the implementation of `lambdaClient.session.updateSessionConfig`, we find that it simply **merges** the new config with the old config.

```typescript
export const sessionRouter = router({
  // ..
  updateSessionConfig: sessionProcedure
    .input(
      z.object({
        id: z.string(),
        value: z.object({}).passthrough().partial(),
      }),
    )
    .mutation(async ({ input, ctx }) => {
      const session = await ctx.sessionModel.findByIdOrSlug(input.id);
      // ...
      const mergedValue = merge(session.agent, input.value);
      return ctx.sessionModel.updateConfig(session.agent.id, mergedValue);
    }),
});
```

Foreseeably, the frontend will add two inputs, calling updateSessionConfig upon user modification. As the current implementation lacks field-level granularity for the config update, the service and model layers remain unaffected.

## 4. Frontend Implementation

### Data Flow Store Implementation

lobe-chat uses [zustand](https://zustand.docs.pmnd.rs/getting-started/introduction) as the global state management framework. For detailed practices on state management, you can refer to [📘 State Management Best Practices](/docs/development/state-management/state-management-intro).

There are two stores related to the agent:

- `src/features/AgentSetting/store` serves the local store for agent settings
- `src/store/agent` is used to get the current session agent's store

The latter listens for and updates the current session's agent configuration through the `onConfigChange` in the `AgentSettings` component in `src/features/AgentSetting/AgentSettings.tsx`.

#### Update AgentSetting/store

First, we update the initialState. After reading `src/features/AgentSetting/store/initialState.ts`, we learn that the initial agent configuration is saved in `DEFAULT_AGENT_CONFIG` in `src/const/settings/agent.ts`:

```diff
export const DEFAULT_AGENT_CONFIG: LobeAgentConfig = {
  chatConfig: DEFAULT_AGENT_CHAT_CONFIG,
  model: DEFAULT_MODEL,
+ openingQuestions: [],
  params: {
    frequency_penalty: 0,
    presence_penalty: 0,
    temperature: 1,
    top_p: 1,
  },
  plugins: [],
  provider: DEFAULT_PROVIDER,
  systemRole: '',
  tts: DEFAUTT_AGENT_TTS_CONFIG,
};
```

Actually, you don't even need to update this since the `openingQuestions` type is already optional. I'm not updating `openingMessage` here.

Because we've added two new fields, to facilitate access by components in the `src/features/AgentSetting/AgentOpening` folder and for performance optimization, we add related selectors in `src/features/AgentSetting/store/selectors.ts`:

```diff
import { DEFAULT_AGENT_CHAT_CONFIG } from '@/const/settings';
import { LobeAgentChatConfig } from '@/types/agent';

import { Store } from './action';

const chatConfig = (s: Store): LobeAgentChatConfig =>
  s.config.chatConfig || DEFAULT_AGENT_CHAT_CONFIG;

+export const DEFAULT_OPENING_QUESTIONS: string[] = [];
export const selectors = {
  chatConfig,
+ openingMessage: (s: Store) => s.config.openingMessage,
+ openingQuestions: (s: Store) => s.config.openingQuestions || DEFAULT_OPENING_QUESTIONS,
};
```

We won't add additional actions to update the agent config here, as I've observed that other existing code also directly uses the unified `setChatConfig` in the existing code:

```typescript
export const store: StateCreator<Store, [['zustand/devtools', never]]> = (set, get) => ({
  setAgentConfig: (config) => {
    get().dispatchConfig({ config, type: 'update' });
  },
});
```

#### Update store/agent

In the component `src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatList/WelcomeChatItem/WelcomeMessage.tsx`, we use this store to get the current agent configuration to render user-customized opening messages and guiding questions.

Since we only need to read two configuration items, we'll simply add two selectors:

Update `src/store/agent/slices/chat/selectors/agent.ts`:

```diff
// ...
+const openingQuestions = (s: AgentStoreState) =>
+  currentAgentConfig(s).openingQuestions || DEFAULT_OPENING_QUESTIONS;
+const openingMessage = (s: AgentStoreState) => currentAgentConfig(s).openingMessage || '';

export const agentSelectors = {
  // ...
  isInboxSession,
+ openingMessage,
+ openingQuestions,
};
```

### UI Implementation and Action Binding

We're adding a new category of settings this time. In `src/features/AgentSetting`, various UI components for agent settings are defined. This time we're adding a setting type: opening settings. We'll add a folder `AgentOpening` to store opening settings-related components. The project uses:

- [ant-design](https://ant.design/) and [lobe-ui](https://github.com/lobehub/lobe-ui): component libraries
- [antd-style](https://ant-design.github.io/antd-style): css-in-js solution
- [react-layout-kit](https://github.com/arvinxx/react-layout-kit): responsive layout components
- [@ant-design/icons](https://ant.design/components/icon-cn) and [lucide](https://lucide.dev/icons/): icon libraries
- [react-i18next](https://github.com/i18next/react-i18next) and [lobe-i18n](https://github.com/lobehub/lobe-cli-toolbox/tree/master/packages/lobe-i18n): i18n framework and multi-language automatic translation tool

lobe-chat is an internationalized project, so newly added text needs to update the default `locale` file: `src/locales/default/setting.ts`.

Let's take the subcomponent `OpeningQuestion.tsx` as an example. Component implementation:

```typescript
// src/features/AgentSetting/AgentOpening/OpeningQuestions.tsx
'use client';

import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { SortableList } from '@lobehub/ui';
import { Button, Empty, Input } from 'antd';
import { createStyles } from 'antd-style';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';

import { useStore } from '../store';
import { selectors } from '../store/selectors';

const useStyles = createStyles(({ css, token }) => ({
  empty: css`
    margin-block: 24px;
    margin-inline: 0;
  `,
  questionItemContainer: css`
    margin-block-end: 8px;
    padding-block: 2px;
    padding-inline: 10px 0;
    background: ${token.colorBgContainer};
  `,
  questionItemContent: css`
    flex: 1;
  `,
  questionsList: css`
    width: 100%;
    margin-block-start: 16px;
  `,
  repeatError: css`
    margin: 0;
    color: ${token.colorErrorText};
  `,
}));

interface QuestionItem {
  content: string;
  id: number;
}

const OpeningQuestions = memo(() => {
  const { t } = useTranslation('setting');
  const { styles } = useStyles();
  const [questionInput, setQuestionInput] = useState('');

  // Use selector to access corresponding configuration
  const openingQuestions = useStore(selectors.openingQuestions);
  // Use action to update configuration
  const updateConfig = useStore((s) => s.setAgentConfig);
  const setQuestions = useCallback(
    (questions: string[]) => {
      updateConfig({ openingQuestions: questions });
    },
    [updateConfig],
  );

  const addQuestion = useCallback(() => {
    if (!questionInput.trim()) return;

    setQuestions([...openingQuestions, questionInput.trim()]);
    setQuestionInput('');
  }, [openingQuestions, questionInput, setQuestions]);

  const removeQuestion = useCallback(
    (content: string) => {
      const newQuestions = [...openingQuestions];
      const index = newQuestions.indexOf(content);
      newQuestions.splice(index, 1);
      setQuestions(newQuestions);
    },
    [openingQuestions, setQuestions],
  );

  // Handle logic after drag and drop sorting
  const handleSortEnd = useCallback(
    (items: QuestionItem[]) => {
      setQuestions(items.map((item) => item.content));
    },
    [setQuestions],
  );

  const items: QuestionItem[] = useMemo(() => {
    return openingQuestions.map((item, index) => ({
      content: item,
      id: index,
    }));
  }, [openingQuestions]);

  const isRepeat = openingQuestions.includes(questionInput.trim());

  return (
    <Flexbox gap={8}>
      <Flexbox gap={4}>
        <Input
          addonAfter={
            <Button
              // don't allow repeat
              disabled={openingQuestions.includes(questionInput.trim())}
              icon={<PlusOutlined />}
              onClick={addQuestion}
              size="small"
              type="text"
            />
          }
          onChange={(e) => setQuestionInput(e.target.value)}
          onPressEnter={addQuestion}
          placeholder={t('settingOpening.openingQuestions.placeholder')}
          value={questionInput}
        />

        {isRepeat && (
          <p className={styles.repeatError}>{t('settingOpening.openingQuestions.repeat')}</p>
        )}
      </Flexbox>

      <div className={styles.questionsList}>
        {openingQuestions.length > 0 ? (
          <SortableList
            items={items}
            onChange={handleSortEnd}
            renderItem={(item) => (
              <SortableList.Item className={styles.questionItemContainer} id={item.id}>
                <SortableList.DragHandle />
                <div className={styles.questionItemContent}>{item.content}</div>
                <Button
                  icon={<DeleteOutlined />}
                  onClick={() => removeQuestion(item.content)}
                  type="text"
                />
              </SortableList.Item>
            )}
          />
        ) : (
          <Empty
            className={styles.empty}
            description={t('settingOpening.openingQuestions.empty')}
          />
        )}
      </div>
    </Flexbox>
  );
});

export default OpeningQuestions;
```

At the same time, we need to display the opening configuration set by the user, which is on the chat page. The corresponding component is in `src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatList/WelcomeChatItem/WelcomeMessage.tsx`:

```typescript
const WelcomeMessage = () => {
  const { t } = useTranslation('chat');

  // Get current opening configuration
  const openingMessage = useAgentStore(agentSelectors.openingMessage);
  const openingQuestions = useAgentStore(agentSelectors.openingQuestions);

  const meta = useSessionStore(sessionMetaSelectors.currentAgentMeta, isEqual);
  const { isAgentEditable } = useServerConfigStore(featureFlagsSelectors);
  const activeId = useChatStore((s) => s.activeId);

  const agentSystemRoleMsg = t('agentDefaultMessageWithSystemRole', {
    name: meta.title || t('defaultAgent'),
    systemRole: meta.description,
  });

  const agentMsg = t(isAgentEditable ? 'agentDefaultMessage' : 'agentDefaultMessageWithoutEdit', {
    name: meta.title || t('defaultAgent'),
    url: `/chat/settings?session=${activeId}`,
  });

  const message = useMemo(() => {
    // Use user-set message if available
    if (openingMessage) return openingMessage;
    return !!meta.description ? agentSystemRoleMsg : agentMsg;
  }, [openingMessage, agentSystemRoleMsg, agentMsg, meta.description]);

  const chatItem = (
    <ChatItem
      avatar={meta}
      editing={false}
      message={message}
      placement={'left'}
      type={type === 'chat' ? 'block' : 'pure'}
    />
  );

  return openingQuestions.length > 0 ? (
    <Flexbox>
      {chatItem}
      {/* Render guiding questions */}
      <OpeningQuestions mobile={mobile} questions={openingQuestions} />
    </Flexbox>
  ) : (
    chatItem
  );
};
export default WelcomeMessage;
```

## 5. Testing

The project uses vitest for unit testing.

Since our two new configuration fields are both optional, theoretically you could pass the tests without updating them. However, since we added the `openingQuestions` field to the `DEFAULT_AGENT_CONFIG` mentioned earlier, this causes many tests to calculate configurations that include this field, so we still need to update some test snapshots.

For the current scenario, I recommend running the tests locally to see which tests fail, and then update them as needed. For example, for the test file `src/store/agent/slices/chat/selectors/agent.test.ts`, you need to run `bunx vitest -u src/store/agent/slices/chat/selectors/agent.test.ts` to update the snapshot.

## Summary

The above is the complete implementation process for the LobeChat opening settings feature. Developers can refer to this document for the development and testing of related features.
